盒子
盒子
文章目录
  1. 0x00 TL;DR
  2. 0x01 Shadow Stack - 原理
    1. 特性:
    2. 影子栈切换:
  3. 0x02 Shadow Stack - 代码分析
  4. 0x03 Shadow Stack - 缓解效果
  5. 0x04 IBT - 原理
    1. 特性:
    2. No-track前缀:
    3. IBT切换:
  6. 0x05 IBT - 代码分析
  7. 0x06 IBT - 缓解效果
  8. 0x07 CET是如何使能的?
  9. 0x08 总结
  10. 0x09 引用

Intel CET缓解机制概述 && 源码分析

0x00 TL;DR

CET(CONTROL-FLOW ENFORCEMENT TECHNOLOGY)机制是Intel提出的用于缓解ROP/JOP/COP的新技术。因其具备“图灵完备”的攻击效果,ROP一直是漏洞利用领域经常使用的攻击技术,在漏洞防御方面,针对ROP攻击技术也不断地在做新的尝试。例如微软的CFG缓解技术,虽然能够起到一定的缓解效果,但是在复杂场景的攻击下还不足够。CET是一项基于硬件支持的解决方案,旨在预防前向(call/jmp)和后向(ret)控制流指令劫持。本文将从CET的设计理念和实际应用出发,探索CET技术在攻防上带来的新变化。文中涉及到的代码均来自linux_cet

0x01 Shadow Stack - 原理

特性:

shadow stack是用于程序控制流转移的第二个栈,与数据栈是分离的,并且可以独立选择在用户模式或特权模式下启用。当shadow stack开启时,CALL指令会把返回地址同时压入数据栈和影子栈(shadow stack),RET指令会把返回地址同时从数据栈和影子栈取出,并比较。如果从两个栈中取出的返回地址不匹配,那么就会触发控制保护异常(#CP)。对影子栈的写入被严格控制在控制传输指令以及影子栈管理指令,这种来自控制传输指令以及影子栈管理指令的加载、存储(读和写)被称为shadow_stack_loadshadow_stack_store,以区别于其他指令,如MOVXSAVES等指令执行的加载和存储。

SSP寄存器:影子栈开启时,CPU会支持一个新的寄存器,为shadow stack pointer(SSP),SSP寄存器在指令中不能直接作为源地址、目标地址以及内存操作数。SSPSP寄存器一样,指向当前影子栈的最顶端。

Supervisor Shadow Stack Token: 在特权内的far CALL或在更高特权调用中断/异常处理的时候,会触发栈切换,如果影子栈在切换的特权中启用的话,也同样会触发影子栈的切换。这种情况下,需要管理员设置的Supervisor Shadow Stack Token用以提供新SSP寄存器的地址。Supervisor Shadow Stack Token的地址存储于IA32_PLx_SSP MSR (0≤ x ≤2)

影子栈切换:

CET提供了一对指令来配合实现栈切换的过程,为RSTORSSPSAVEPREVSSPRSTORSSP指令用于验证新影子栈上的shadow-stack-restore token,验证有效后将SSP切换到该token去。该token字节格式如下:

Bit 63:2 :影子栈指针的值(当还原点被创建的时候)

Bit 1 :保留,为0

Bit 0 :为0代表传统模式的shadow-stack-restore token,为1代表64位模式下可以被RSTORSSP指令使用

shadow-stack-restore token是被SAVEPREVSSP指令所创建的,操作系统也可以在影子栈上创建还原点。一旦使用RSTORSSP指令切换到新的影子栈,便可以执行SAVEPREVSSP指令在旧的影子栈创建还原点。为了让SAVEPREVSSP指令确定保存shadow-stack-restore token的地址,RESTORSSP指令会用previous-ssp token(包含了指令调用时的SSP的值)替换shadow-stack-restore tokenprevious-ssp token字节格式如下:

Bit 63:2 :RSTORSSP指令调用时的影子栈指针,即SSP的值

Bit 1 :设置为1

Bit 0 :模式位。为0代表可以在传统模式下被SAVEPREVSSP指令使用,为1代表可以在64位模式下被使用。

下面用图示来描述一下影子栈切换的过程:

  • RSTORSSP切换影子栈

RSTORSSP

切换前的影子栈SSP的值为1000H,先检查新影子栈的shadow-stack-restore token,在3FF8H处,保存着还原点创建时候的SSP,这个例子中为4000H。随后将SSP切换到3FF8H处,在将3FF8H处替换为previous-ssp token,即1000HRSTORSSP调用时的SSP值)。

  • SAVEPREVSSP保存还原点

SAVEPREVSSP

为了能够切换回旧的影子栈,需要调用SAVEPREVSSP。先找到previous-ssp token,在3FF8H处。随后在旧的影子栈中保存shadow-stack-restore token,在FF8H处,其中保存的值为记录在previous-ssp token中的地址1000H。最终,SAVEPREVSSP会将当前影子栈中的previous-ssp tokenpopINCSSP指令)出来,SSP变为4000H

总结来说,RSTORSSP指令包含验证shadow-stack-restore token、切换SSP、设置previous-ssp tokenSAVEPREVSSP指令包含找到previous-ssp token、设置shadow-stack-restore token、弹出previous-ssp token

0x02 Shadow Stack - 代码分析

理论说再多也没有直接看代码来的实在,CET具体在Linux内核中是怎么实现的,下面一起来看看。

首先看shstk_setup()函数/arch/x86/kernel/shstk.c,这个函数会在arch_setup_elf_property函数中被调用,用于elf在系统中加载执行的时候设置当前进程的影子栈:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
struct thread_shstk { //进程中的shstk结构体,存储着影子栈的页地址和页大小
u64 base;
u64 size;
u64 locked:1;
u64 ibt:1;
};

int shstk_setup(void)
{
struct thread_shstk *shstk = &current->thread.shstk;
unsigned long addr, size;
int err;

//...
size = round_up(min_t(unsigned long long, rlimit(RLIMIT_STACK), SZ_4G), PAGE_SIZE); //设置页大小,这里跟普通数据栈的最大size一样(默认0x800000字节)
addr = alloc_shstk(size); //申请影子栈
if (IS_ERR_VALUE(addr))
return PTR_ERR((void *)addr);

start_update_msrs();
err = wrmsrl_safe(MSR_IA32_PL3_SSP, addr + size); //设置r3的ssp寄存器
if (!err)
wrmsrl_safe(MSR_IA32_U_CET, CET_SHSTK_EN); //设置user mode cet setting
end_update_msrs();

if (!err) {
shstk->base = addr;
shstk->size = size;
}

return err;
}

static unsigned long alloc_shstk(unsigned long size)
{
int flags = MAP_ANONYMOUS | MAP_PRIVATE;
unsigned long addr, populate;

//...
addr = do_mmap(NULL, 0, size, PROT_READ, flags, VM_SHADOW_STACK, 0,
&populate, NULL); //内存映射,页属性设置为只读,还带了一个VM_SHADOW_STACK标志参数

return addr;
}

由此可见影子栈就是映射了一块只读、匿名且私有的内存。

往后再看shstk_alloc_thread_stack()函数,该函数会被copy_thread函数调用。用于在fork、vfork、clone等多进程系统调用中创建新的影子栈。例如当使用fork系统调用的时候,子进程不会与父进程共享内存,而是单独分一块内存,因此这种情况下子进程的影子栈也应当与父进程的影子栈区分开来,需要再申请一块影子栈给子进程使用。但是这里vfork是个例外,因为vfork会使得子进程与父进程共享同一块内存,就不存在前面这种情况了。这一块的代码也是容易理解的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
int shstk_alloc_thread_stack(struct task_struct *tsk, unsigned long clone_flags,
unsigned long stack_size)
{
struct thread_shstk *shstk = &tsk->thread.shstk;
struct cet_user_state *state;
unsigned long addr;

//...
// CLONE_VM标志下,除了vfork,子进程都需要一个单独的影子栈
if ((clone_flags & (CLONE_VFORK | CLONE_VM)) != CLONE_VM)
return 0;

state = get_xsave_addr(&tsk->thread.fpu.state.xsave, XFEATURE_CET_USER);
if (WARN_ON_ONCE(!state))
return -EINVAL;

//...

stack_size = round_up(stack_size, PAGE_SIZE);
addr = alloc_shstk(stack_size);
if (IS_ERR_VALUE(addr)) {
shstk->base = 0;
shstk->size = 0;
return PTR_ERR((void *)addr);
}

state->user_ssp = (u64)(addr + stack_size); //设置用户态ssp寄存器
shstk->base = addr;
shstk->size = stack_size;
return 0;
}

接下来还有影子栈free以及disable两个函数,也容易理解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
void shstk_disable(void)
{
struct thread_shstk *shstk = &current->thread.shstk;
u64 msr_val;

//...

start_update_msrs();
rdmsrl(MSR_IA32_U_CET, msr_val); //读取user mode cet setting,前面setup的时候设置了CET_SHSTK_EN
wrmsrl(MSR_IA32_U_CET, msr_val & ~CET_SHSTK_EN); //擦除CET_SHSTK_EN标志
wrmsrl(MSR_IA32_PL3_SSP, 0); //r3的ssp寄存器也清0
end_update_msrs();

shstk_free(current); //释放内存
}

void shstk_free(struct task_struct *tsk)
{
struct thread_shstk *shstk = &tsk->thread.shstk;

//...

while (1) {
int r;

r = vm_munmap(shstk->base, shstk->size); //取消映射内存

if (r == -EINTR) {
cond_resched();
continue;
}
//...
}

shstk->base = 0; //置0
shstk->size = 0;
}

再来看比较关键的setup_signal_shadow_stack()restore_signal_shadow_stack()函数,这两个函数分别会被__setup_rt_framert_sigreturn调用,即都应用于信号处理这一部分,分别对应着信号注册和信号返回。先看setup_signal_shadow_stack()函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
int setup_signal_shadow_stack(int ia32, void __user *restorer) //restorer在信号处理过程中代表着
//用户态定义的信号处理函数结束后调
//的函数,默认调用
//__NR_rt_sigreturn
{
struct thread_shstk *shstk = &current->thread.shstk;
unsigned long new_ssp;
int err;

//...
err = shstk_setup_rstor_token(ia32, (unsigned long)restorer,
&new_ssp);

start_update_msrs();
err = wrmsrl_safe(MSR_IA32_PL3_SSP, new_ssp); //更新r3的ssp寄存器
end_update_msrs();

return err;
}

int shstk_setup_rstor_token(bool ia32, unsigned long ret_addr,
unsigned long *new_ssp)
{
struct thread_shstk *shstk = &current->thread.shstk;
unsigned long ssp, token_addr;
int err;

//...
ssp = get_user_shstk_addr(); //获取当前ssp栈顶的值

err = create_rstor_token(ia32, ssp, &token_addr); //创建一个还原点(shadow-stack-restore token)

//...
} else { //将ret_addr地址push进影子栈
ssp = token_addr - sizeof(u64);
err = write_user_shstk_64((u64 __user *)ssp, (u64)ret_addr);
}

if (!err)
*new_ssp = ssp; //更新ssp的值

return err;
}

static int create_rstor_token(bool ia32, unsigned long ssp,
unsigned long *token_addr)
{
unsigned long addr;

//...
addr = ALIGN_DOWN(ssp, 8) - 8;

/* Is the token for 64-bit? */
if (!ia32)
ssp |= BIT(0);

if (write_user_shstk_64((u64 __user *)addr, (u64)ssp)) //把ssp的值push到影子栈中
return -EFAULT;

*token_addr = addr; //token_addr设置为ssp-8(即push后的ssp值)

return 0;
}

该函数整体看下来,就是实现了最开始理论部分的shadow-stack-restore token,即创建还原点的过程。用图简单表示一下:

restor_token

同样的,restore_signal_shadow_stack()从名字上就可以大致猜到是用于还原影子栈的函数了,具体看代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
int restore_signal_shadow_stack(void)
{
struct thread_shstk *shstk = &current->thread.shstk;
int ia32 = in_ia32_syscall();
unsigned long new_ssp;
int err;

//...
err = shstk_check_rstor_token(ia32, &new_ssp);

start_update_msrs();
err = wrmsrl_safe(MSR_IA32_PL3_SSP, new_ssp); //更新r3的ssp寄存器
end_update_msrs();

return err;
}

int shstk_check_rstor_token(bool proc32, unsigned long *new_ssp)
{
unsigned long token_addr;
unsigned long token;
bool shstk32;

token_addr = get_user_shstk_addr(); //获取当前ssp栈顶的值

if (get_user(token, (unsigned long __user *)token_addr)) //pop栈顶的值到token
return -EFAULT;

/* Is mode flag correct? */
shstk32 = !(token & BIT(0)); //判断创建还原点时的位数是否为32位
if (proc32 ^ shstk32) //判断当前位数与创建还原点的位数是否相同
return -EINVAL;

//...

*new_ssp = token; //token作为新的ssp

return 0;
}

该函数正如所猜想的,就是用于还原影子栈。拿上面表示创建shadow-stack-restore token的图来说,就是将0x1001去除标志位后赋值给ssp寄存器,作为新的影子栈栈顶使用。

最后,在shadow stack的实现里,还将创建的过程单独实现了一个API,使得能够在用户态创建影子栈,具体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
SYSCALL_DEFINE2(arch_prctl, int, option, unsigned long, arg2)
{
long ret;

//...
ret = do_arch_prctl_common(current, option, arg2);

return ret;
}

long do_arch_prctl_common(struct task_struct *task, int option,
unsigned long arg2)
{
//...
return prctl_cet(option, arg2);
}

int prctl_cet(int option, u64 arg2)
{
//...
#ifdef CONFIG_X86_SHADOW_STACK
case ARCH_X86_CET_ALLOC_SHSTK:
return handle_alloc_shstk(arg2);
#endif

}

unsigned long cet_alloc_shstk(unsigned long len)
{
unsigned long token;
unsigned long addr, ssp;

addr = alloc_shstk(round_up(len, PAGE_SIZE)); //创建影子栈

ssp = addr + len;
token = ssp;

if (!in_ia32_syscall())
token |= BIT(0);
ssp -= 8;

if (write_user_shstk_64((u64 __user *)ssp, (u64)token)) { //push token
//...
}

return addr;
}

也就是说我们可以在用户态用如下系统调用的语句来创建影子栈:

1
2
3
4
uint64_t buf[3] = {0};
buf[0] = 0x1000;

syscall(SYS_arch_prctl, ARCH_X86_CET_ALLOC_SHSTK, buf);

以上就是Linux中实现shadow stack的大概了,对照着白皮书上的相关概念来看,目前只实现了一部分,还有不少需要添加的地方,例如ring0层面的shadow stack、系统调用的影子栈切换、进程间的影子栈切换…

0x03 Shadow Stack - 缓解效果

分析代码往往还不够,因为还并没有真正意义上的动手操作,下面就实际看看shadow stack缓解ROP的效果如何。

demo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>

int func(){
int b[1];

b[0] = 0x90909090;
b[1] = 0x90909090;
b[2] = 0x90909090;
b[3] = 0x90909090;
}

int main(){
func();
return 0;
}

gdb中的情况,返回地址已被修改,和shadow stack中保存的返回地址并不相同,继续执行会导致崩溃:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
gdb-peda$ disassemble
Dump of assembler code for function func:
0x00000000004005c6 <+0>: endbr64
0x00000000004005ca <+4>: push rbp
0x00000000004005cb <+5>: mov rbp,rsp
0x00000000004005ce <+8>: mov DWORD PTR [rbp-0x4],0x90909090
0x00000000004005d5 <+15>: mov DWORD PTR [rbp+0x0],0x90909090
0x00000000004005dc <+22>: mov DWORD PTR [rbp+0x4],0x90909090
0x00000000004005e3 <+29>: mov DWORD PTR [rbp+0x8],0x90909090
0x00000000004005ea <+36>: nop
0x00000000004005eb <+37>: pop rbp
=> 0x00000000004005ec <+38>: ret
End of assembler dump.

gdb-peda$ x/2xg $rsp
0x7fffffffe148: 0x0000000090909090 0x0000000000400610

gdb-peda$ vmmap 0x7ffff7dcfff8
Start End Perm Name
0x00007ffff75d0000 0x00007ffff7dd0000 r--p [shadow stack]

gdb-peda$ x/3xg 0x00007ffff7dd0000 - 0x18
0x7ffff7dcffe8: 0x00000000004005ff 0x00007ffff722d493
0x7ffff7dcfff8: 0x000000000040050e

gdb-peda$ disassemble 0x00000000004005ff
Dump of assembler code for function main:
0x00000000004005ed <+0>: endbr64
0x00000000004005f1 <+4>: push rbp
0x00000000004005f2 <+5>: mov rbp,rsp
0x00000000004005f5 <+8>: mov eax,0x0
0x00000000004005fa <+13>: call 0x4005c6 <func>
0x00000000004005ff <+18>: mov eax,0x0 //影子栈中保存的返回地址
0x0000000000400604 <+23>: pop rbp
0x0000000000400605 <+24>: ret
End of assembler dump.

gdb-peda$ ni
Program received signal SIGSEGV, Segmentation fault.

内核日志:

内核日志

0x04 IBT - 原理

特性:

IBT(indirect branch tracker)应用于间接跳转(jmp/call指令),如果在间接跳转后的下一条指令不是ENDBR32ENDBR64,就会触发#CP异常。并不包括RIP相对跳转、远直接jmp跳转、call相对跳转等,这些都是跳转到固定地址,不存在被篡改的可能,因此IBT并不作用于这种情况。

ENDBR:在不支持CET的英特尔CPU上,ENDBR32ENDBR64指令有着同样的作用,可以视为和NOP指令一样。因此无论在支持或不支持CET的处理器上,带IBT特性的程序执行过程都一样。

双状态机:处理器实现了两个双状态机去跟踪间接跳转,一个用于用户态,一个用于内核(特权)态。两个状态机初始都为IDLE状态。当执行一个间接跳转(CALLJMP指令)时,状态机会转变为WAIT_FOR_ENDBRANCH状态。在WAIT_FOR_ENDBRANCH状态时,状态机会验证下一个指令是否为ENDBR32ENDBR64,不是则抛#CP异常。

No-track前缀:

CET允许软件指定某个间接跳转指令为”不跟踪间接跳转“,即使得IBT暂时失效。这种情况下可以在CALL/JMP处添加no-track前缀。通过在IA32_U_CET/IA32_S_CET MSR寄存器使能NO_TRACK_EN,就可以使得带3EH前缀(no-track前缀)的近地址间接跳转指令(near indirect CALL/JMP)不改变IBT。远地址间接跳转指令(Far CALL/JMP)始终会被IBT跟踪且忽略3EH前缀。当NO_TRACK_EN控制为0时,无论是否带3EH前缀,近地址间接跳转也始终会被跟踪。

IBT切换:

CPL 3和CPL < 3之间:

一个在用户态(CPL == 3)执行的进程因为中断切换到内核态(CPL < 3),会导致用户态状态机切换到内核态状态机,并且用户态的IBT会变为inactive,内核态的IBT会变为active。后续的IRET指令会将进程从中断处理(CPL < 3)返回到用户态(CPL == 3)进程,同时会导致内核态IBT变为inactive,用户态IBT变为active。具体分为下面三种情况,所有情况中源IBT状态都变为inactive且保持状态机的状态:

  • 情况1:Far CALL/JMP,SYSCALL/SYSENTER

目标IBT状态变为active,并且不受抑制,状态机转变为WAIT_FOR_ENDBRANCH。因此这也强制要求子例程被far CALL/JMP调用时必须要以ENDBRANCH开头。

  • 情况2:硬件中断/陷阱/异常/NMI/软件中断/Machine Checks

目标IBT状态变为active,并且不受抑制,状态机转变为WAIT_FOR_ENDBRANCH

  • 情况3:IRET/Far RET

目标IBT状态变为active,并保持状态机的状态。如果用户态被更高优先级的事件中断,例如在间接跳转最后的中断,那么当使用IRETFar RET返回被中断用户态时,用户态IBT会保持状态机的状态并且验证下一个指令不是ENDBR32ENDBR64时触发#CP异常。

CPL < 3内:

这种情况下在控制流程开始到结束,用的都是同一个IBT且为active,还是分为三种情况:

  • 情况1:Far CALL/JMP, Near indirect CALL/JMPCALL/JMP

Far CALL/JMP:不受抑制且转变为WAIT_FOR_ENDBRANCH

Near indirect CALL/JMPCALL/JMP:不受抑制且转变为WAIT_FOR_ENDBRANCH

  • 情况2:硬件中断/陷阱/异常/NMI/软件中断/Machine Checks

不受抑制且转变为WAIT_FOR_ENDBRANCH

  • 情况3:IRET

保持状态机的状态。

0x05 IBT - 代码分析

在内核实现中,有关IBT的代码比较少,先来看初始化函数ibt_setup()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
int ibt_setup(void)
{
int r;

//...
r = ibt_set_clear_msr_bits(CET_ENDBR_EN | CET_NO_TRACK_EN, 0); //使能IBT和no-track前缀
if (!r)
current->thread.shstk.ibt = 1;

return r;
}

static int ibt_set_clear_msr_bits(u64 set, u64 clear) //user cet上做设置和清楚标志位
{
u64 msr;
int r;

//...
r = rdmsrl_safe(MSR_IA32_U_CET, &msr);
if (!r) {
msr = (msr & ~clear) | set;
r = wrmsrl_safe(MSR_IA32_U_CET, msr);
}

return r;
}

初始化函数还是很简短的,同样地,关闭函数也很容易理解:

1
2
3
4
5
6
void ibt_disable(void)
{
//...
ibt_set_clear_msr_bits(0, CET_ENDBR_EN); //关闭IBT
current->thread.shstk.ibt = 0;
}

接下去看ibt_get_clear_wait_endbr()函数,该函数也会被__setup_rt_frame函数调用,由此可见也是作用在信号处理过程当中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int ibt_get_clear_wait_endbr(void)
{
u64 msr_val = 0;

//...
if (!test_thread_flag(TIF_NEED_FPU_LOAD)) {
if (!rdmsrl_safe(MSR_IA32_U_CET, &msr_val))
wrmsrl(MSR_IA32_U_CET, msr_val & ~CET_WAIT_ENDBR); //清除CET_WAIT_ENDBR标志位,即状态机从WAIT_FOR_ENDBRANCH变为IDLE
} else {
//...
}

return msr_val & CET_WAIT_ENDBR; //验证是否包含CET_WAIT_ENDBR标志位
}

流程比较简单,即在信号处理函数后、执行下一个函数前将状态机复位为IDLE状态。

与之对应的是ibt_set_wait_endbr()函数,该函数会被rt_sigreturn调用,即返回到被信号中断的用户态函数前将状态机转变为WAIT_FOR_ENDBRANCH

1
2
3
4
5
int ibt_set_wait_endbr(void)
{
//...
return ibt_set_clear_msr_bits(CET_WAIT_ENDBR, 0); //设置CET_WAIT_ENDBR标志位,状态机转变为WAIT_FOR_ENDBRANCH
}

以上就是IBT机制的大部分实现了,实现的地方比较少,一是因为CET总体的实现还不完善,例如没有系统调用时IBT跟踪ENDBR的实现等,二是因为IBT机制更多的是在CPU硬件层面上的实现,例如执行间接跳转CALL/JMP指令时会检查下一条指令是否为ENDBR32ENDBR64,这种实现都是在硬件层面做的,因此IBT机制需要在操作系统层面需要做的改动就少一些。

0x06 IBT - 缓解效果

demo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>
void shell(){
system("/bin/sh");
return;
}

void normal(){
printf("crrocet control.\n");
return;
}

typedef struct stru{
void (*ops)(void);
int num;
}stru;

int main(){

uint32_t over[2] = {0};

stru stru1 = {0};
stru1.ops = normal;
stru1.num = 0x100;

stru1.ops(); // crrocet flow

over[0] = 0x90909090;
over[1] = 0x90909090;
over[-6] = 0x40068a;

if(!over[0]){
normal();
}
else{
stru1.ops(); // ROP flow
}

return 0;
}

gdb中的情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
//正常调用stru1.ops()时,为间接跳转call
0x00000000004006e3 <+51>: mov rax,QWORD PTR [rbp-0x20]
=> 0x00000000004006e7 <+55>: call rax
0x00000000004006e9 <+57>: mov DWORD PTR [rbp-0x8],0x90909090

//over[-6] = 0x40068a 将stru1.ops篡改了
gdb-peda$ p stru1
$2 = {
ops = 0x40068a <_dl_relocate_static_pie>,
num = 0x100
}

gdb-peda$ disassemble 0x40068a
Dump of assembler code for function shell:
0x0000000000400686 <+0>: endbr64 //IBT需要验证的endbr64指令
0x000000000040068a <+4>: push rbp //篡改的地址
0x000000000040068b <+5>: mov rbp,rsp
0x000000000040068e <+8>: mov edi,0x4007b8
0x0000000000400693 <+13>: call 0x400590 <system@plt>
0x0000000000400698 <+18>: nop
0x0000000000400699 <+19>: pop rbp
0x000000000040069a <+20>: ret
End of assembler dump.

//再次调用stru1.ops()时,崩溃
gdb-peda$ ni
Program received signal SIGSEGV, Segmentation fault.

内核日志:

内核日志2

0x07 CET是如何使能的?

再来深度的思考一个问题,CET是如何使能的,为什么编译了一个ELF文件这个文件就支持了CET呢?答案得从编译器和内核两头中去找,先看编译器的部分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
void
file_end_indicate_exec_stack_and_cet (void)
{
file_end_indicate_exec_stack ();
if (flag_cf_protection == CF_NONE)
return;
unsigned int feature_1 = 0;
if (TARGET_IBT)
/* GNU_PROPERTY_X86_FEATURE_1_IBT. */
feature_1 |= 0x1; //添加IBT机制属性
if (TARGET_SHSTK)
/* GNU_PROPERTY_X86_FEATURE_1_SHSTK. */
feature_1 |= 0x2; //添加SHSTK机制属性
if (feature_1) //在相关的段.note.gnu.property中做标记
{
int p2align = ptr_mode == SImode ? 2 : 3;
/* Generate GNU_PROPERTY_X86_FEATURE_1_XXX. */
switch_to_section (get_section (".note.gnu.property",
SECTION_NOTYPE, NULL));
//...
}
}

简单来说就是会在编译阶段在ELF相关的段上做CET的标记,存在两个featureGNU_PROPERTY_X86_FEATURE_1_IBTGNU_PROPERTY_X86_FEATURE_1_SHSTK

再来看内核中的实现,直接看内核加载ELF文件的函数load_elf_binary(),关键在于arch_setup_elf_property函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
static int load_elf_binary(struct linux_binprm *bprm)
{
//...
retval = arch_setup_elf_property(&arch_state);
if (retval < 0)
goto out;
//...
}

int arch_setup_elf_property(struct arch_elf_state *state)
{
int r = 0;

#ifdef CONFIG_X86_SHADOW_STACK
memset(&current->thread.shstk, 0, sizeof(struct thread_shstk));

if (state->gnu_property & GNU_PROPERTY_X86_FEATURE_1_SHSTK) //判断elf是否带有SHSTK属性
r = shstk_setup();

if (r < 0)
return r;

if (state->gnu_property & GNU_PROPERTY_X86_FEATURE_1_IBT) //判断elf是否带有IBT属性
r = ibt_setup();
#endif

return r;
}

从上面的函数可以看出在gcc中添加的属性,在内核中得到了验证,若存在,则进程加载的过程中内核就为进程开启了CET机制。由此就可以知道CET是如何使能的大概过程了。

0x08 总结

以上就是CET的概述了。总的来说CET在硬件层面实现的缓解机制与以往的软件层面缓解机制有着比较大的不同,在性能上面就比软件实现的快了许多,且从目前的情况来看,还没有很有效的绕过CET的方法,在缓解能力方面也比以往的缓解措施加强了许多。目前CET机制还没有很广泛的使用,从Linux上的具体实现就可以看出来,但在不久的将来,随着CET的推广,CET机制又会给ROP攻击手法带来较大的困难甚至根除ROP。道高一尺,魔高一丈,期待在未来的攻防对抗过程中又能碰撞出不一样的火花。

0x09 引用

  1. https://github.com/yyu168/linux_cet
  2. https://www.intel.com/content/www/us/en/develop/articles/technical-look-control-flow-enforcement-technology.html
  3. https://www.intel.com/content/dam/develop/external/us/en/documents/catc17-introduction-intel-cet-844137.pdf
  4. https://windows-internals.com/cet-on-windows/
  5. http://readm.tech/2016/11/09/cet-shadow_stacks/
  6. https://www.offensive-security.com/offsec/intel-cet-in-action/#cet1
支持一下
扫一扫,支持v1nke
  • 微信扫一扫
  • 支付宝扫一扫